<?php

namespace App\System\Component\Execl\Reader;

use Iterator;
use Countable;
use Exception;

/**
 * Class for parsing CSV files
 *
 * @author Martins Pilsetnieks
 */
class SpreadsheetReader_CSV implements Iterator, Countable {
	/**
	 *
	 * @var array Options array, pre-populated with the default values.
	 */
	private $Options = array (
			'Delimiter' => ';',
			'Enclosure' => '"' 
	);
	private $Encoding = 'UTF-8';
	private $BOMLength = 0;
	
	/**
	 *
	 * @var resource File handle
	 */
	private $Handle = false;
	private $Filepath = '';
	private $Index = 0;
	private $CurrentRow = null;
	
	/**
	 *
	 * @param
	 *        	string Path to file
	 * @param
	 *        	array Options:
	 *        	Enclosure => string CSV enclosure
	 *        	Separator => string CSV separator
	 */
	public function __construct($Filepath, array $Options = null) {
		$this->Filepath = $Filepath;
		
		if (! is_readable ( $Filepath )) {
			throw new Exception ( 'SpreadsheetReader_CSV: File not readable (' . $Filepath . ')' );
		}
		
		// For safety's sake
		@ini_set ( 'auto_detect_line_endings', true );
		
		$this->Options = array_merge ( $this->Options, $Options );
		$this->Handle = fopen ( $Filepath, 'r' );
		
		// Checking the file for byte-order mark to determine encoding
		$BOM16 = bin2hex ( fread ( $this->Handle, 2 ) );
		if ($BOM16 == 'fffe') {
			$this->Encoding = 'UTF-16LE';
			// $this -> Encoding = 'UTF-16';
			$this->BOMLength = 2;
		} elseif ($BOM16 == 'feff') {
			$this->Encoding = 'UTF-16BE';
			// $this -> Encoding = 'UTF-16';
			$this->BOMLength = 2;
		}
		
		if (! $this->BOMLength) {
			fseek ( $this->Handle, 0 );
			$BOM32 = bin2hex ( fread ( $this->Handle, 4 ) );
			if ($BOM32 == '0000feff') {
				// $this -> Encoding = 'UTF-32BE';
				$this->Encoding = 'UTF-32';
				$this->BOMLength = 4;
			} elseif ($BOM32 == 'fffe0000') {
				// $this -> Encoding = 'UTF-32LE';
				$this->Encoding = 'UTF-32';
				$this->BOMLength = 4;
			}
		}
		
		fseek ( $this->Handle, 0 );
		$BOM8 = bin2hex ( fread ( $this->Handle, 3 ) );
		if ($BOM8 == 'efbbbf') {
			$this->Encoding = 'UTF-8';
			$this->BOMLength = 3;
		}
		
		// Seeking the place right after BOM as the start of the real content
		if ($this->BOMLength) {
			fseek ( $this->Handle, $this->BOMLength );
		}
		
		// Checking for the delimiter if it should be determined automatically
		if (! $this->Options ['Delimiter']) {
			// fgetcsv needs single-byte separators
			$Semicolon = ';';
			$Tab = "\t";
			$Comma = ',';
			
			// Reading the first row and checking if a specific separator character
			// has more columns than others (it means that most likely that is the delimiter).
			$SemicolonCount = count ( fgetcsv ( $this->Handle, null, $Semicolon ) );
			fseek ( $this->Handle, $this->BOMLength );
			$TabCount = count ( fgetcsv ( $this->Handle, null, $Tab ) );
			fseek ( $this->Handle, $this->BOMLength );
			$CommaCount = count ( fgetcsv ( $this->Handle, null, $Comma ) );
			fseek ( $this->Handle, $this->BOMLength );
			
			$Delimiter = $Semicolon;
			if ($TabCount > $SemicolonCount || $CommaCount > $SemicolonCount) {
				$Delimiter = $CommaCount > $TabCount ? $Comma : $Tab;
			}
			
			$this->Options ['Delimiter'] = $Delimiter;
		}
	}
	
	/**
	 * Returns information about sheets in the file.
	 * Because CSV doesn't have any, it's just a single entry.
	 *
	 * @return array Sheet data
	 */
	public function Sheets() {
		return array (
				0 => basename ( $this->Filepath ) 
		);
	}
	
	/**
	 * Changes sheet to another.
	 * Because CSV doesn't have any sheets
	 * it just rewinds the file so the behaviour is compatible with other
	 * sheet readers. (If an invalid index is given, it doesn't do anything.)
	 *
	 * @param
	 *        	bool Status
	 */
	public function ChangeSheet($Index) {
		if ($Index == 0) {
			$this->rewind ();
			return true;
		}
		return false;
	}
	
	// !Iterator interface methods
	/**
	 * Rewind the Iterator to the first element.
	 * Similar to the reset() function for arrays in PHP
	 */
	public function rewind() {
		fseek ( $this->Handle, $this->BOMLength );
		$this->CurrentRow = null;
		$this->Index = 0;
	}
	
	/**
	 * Return the current element.
	 * Similar to the current() function for arrays in PHP
	 *
	 * @return mixed current element from the collection
	 */
	public function current() {
		if ($this->Index == 0 && is_null ( $this->CurrentRow )) {
			$this->next ();
			$this->Index --;
		}
		return $this->CurrentRow;
	}
	
	/**
	 * Move forward to next element.
	 * Similar to the next() function for arrays in PHP
	 */
	public function next() {
		$this->CurrentRow = array ();
		
		// Finding the place the next line starts for UTF-16 encoded files
		// Line breaks could be 0x0D 0x00 0x0A 0x00 and PHP could split lines on the
		// first or the second linebreak leaving unnecessary \0 characters that mess up
		// the output.
		if ($this->Encoding == 'UTF-16LE' || $this->Encoding == 'UTF-16BE') {
			while ( ! feof ( $this->Handle ) ) {
				// While bytes are insignificant whitespace, do nothing
				$Char = ord ( fgetc ( $this->Handle ) );
				if (! $Char || $Char == 10 || $Char == 13) {
					continue;
				} else {
					// When significant bytes are found, step back to the last place before them
					if ($this->Encoding == 'UTF-16LE') {
						fseek ( $this->Handle, ftell ( $this->Handle ) - 1 );
					} else {
						fseek ( $this->Handle, ftell ( $this->Handle ) - 2 );
					}
					break;
				}
			}
		}
		
		$this->Index ++;
		$this->CurrentRow = fgetcsv ( $this->Handle, null, $this->Options ['Delimiter'], $this->Options ['Enclosure'] );
		
		if ($this->CurrentRow) {
			// Converting multi-byte unicode strings
			// and trimming enclosure symbols off of them because those aren't recognized
			// in the relevan encodings.
			if ($this->Encoding != 'ASCII' && $this->Encoding != 'UTF-8') {
				$Encoding = $this->Encoding;
				foreach ( $this->CurrentRow as $Key => $Value ) {
					$this->CurrentRow [$Key] = trim ( trim ( mb_convert_encoding ( $Value, 'UTF-8', $this->Encoding ), $this->Options ['Enclosure'] ) );
				}
			}
		}
		
		return $this->CurrentRow;
	}
	
	/**
	 * Return the identifying key of the current element.
	 * Similar to the key() function for arrays in PHP
	 *
	 * @return mixed either an integer or a string
	 */
	public function key() {
		return $this->Index;
	}
	
	/**
	 * Check if there is a current element after calls to rewind() or next().
	 * Used to check if we've iterated to the end of the collection
	 *
	 * @return boolean FALSE if there's nothing more to iterate over
	 */
	public function valid() {
		return ($this->CurrentRow || ! feof ( $this->Handle ));
	}
	
	// !Countable interface method
	/**
	 * Ostensibly should return the count of the contained items but this just returns the number
	 * of rows read so far.
	 * It's not really correct but at least coherent.
	 */
	public function count() {
		return $this->Index + 1;
	}
}
?>